开发环境:
MDK:Keil 5.30
开发板:GD32F207I-EVAL
MCU:GD32F207IK
7.1定时器的工作原理概述
系统滴答定时器一般用来提供“心跳”作用,而GD32定时器最基本功能也是定时,可以设置不同时间长度的定时。定时器除了最基本的定时功能外,定时器与GPIO有挂钩使得它可以发挥强大的作用,比如可以输出不同频率、不同占空比的方波信号、PWM信号,同时做为输入捕获功能时,可以测量脉冲宽度、实现电容按键检测等等。
GD32有三类定时器,基本定时器就是单纯的定时计数器,通用定时器多了四个通道,相对应的增加了功能,高级定时器具有基本,通用定时器的所有的功能,并且添加了其他功能。定时器的对比特性如下表所示。
定时器 | 定时器0/7 | 定时器1/2/3/4 | 定时器8/11 | 定时器9/10/12/13 | 定时器 5/6 |
---|---|---|---|---|---|
类型 | 高级 | 通用(L0) | 通用(L1) | 通用(L2) | 基本 |
预分频器 | 16 位 | 16 位 | 16 位 | 16 位 | 16 位 |
计数器 | 16 位 | 16 位 | 16 位 | 16 位 | 16 位 |
计数模式 | 向上, 向下, 中央对齐 |
向上, 向下, 中央对齐 |
向上, 向下, 中央对齐 |
向上, 向下, 中央对齐 |
向上 |
7.1.1基本定时器
TIMER5和TIMER6定时器的主要功能包括:
● 16位自动重装载累加计数器
● 16位可编程(可实时修改)预分频器,用于对输入的时钟按系数为1 ~ 65536之间的任意数值分频
● 时钟源只有内部时钟
● 在更新事件(计数器溢出)时产生中断/DMA请求
总的说来,基本定时器 TIMER5和TIMER6只具备最基本的定时功能,就是累加的时钟脉冲数超过预定值时,能触发中断或触发 DMA 请求。由于在芯片内部与 DAC 外设相连,可通过触发输出驱动 DAC,也可以作为其他通用定时器的时钟基准。
这两个基本定时器使用的时钟源都是CK_TIMER驱动,时钟源经过 TIMERx_PSC预分频器输入至脉冲计数器TIMERx_CNT,基本定时器只能工作在向上计数模式,在重载寄存器TIMERx_CAR中保存的是定时器的溢出值。
工作时,脉冲计数器TIMERx_CNT由时钟触发进行计数,当 TIMx_CNT 的计数值 X 等于重载寄存器TIMERx_CAR中保存的数值 N 时,产生溢出事件,可触发中断或 DMA 请求。然后TIMERx_CNT的值重新被置为 0,重新向上计数。
7.1.2通用定时器
通用TIMERx(TIMER1/ TIMER2/ TIMER3/ TIMER4/ TIMER8/ TIMER11/ TIMER9/ TIMER10/ TIMER12/ TIMER13)定时器功能包括:
● 16位向上、向下、向上/向下自动装载计数器;
● 16位可编程(可以实时修改)预分频器,计数器时钟频率的分频系数为1~65536之间的任意数值;
● 4个独立通道:输入捕获,输出比较,PWM生成(边缘或中间对齐模式),单脉冲模式输出;
● 使用外部信号控制定时器和定时器互连的同步电路;
● 如下事件发生时产生中断/DMA:更新:计数器向上溢出/向下溢出,计数器初始化(通过软件或者内部/外部触发), 触发事件(计数器启动、停止、初始化或者由内部/外部触发计数),输入捕获,输出比较;
● 支持针对定位的增量(正交)编码器和霍尔传感器电路;
● 时钟源可选:内部时钟,内部触发,外部输入,外部触发;
相比之下,通用定时器就比基本定时器复杂得多了。除了基本的定时,它主要用在测量输入脉冲的频率、脉冲宽与输出 PWM 脉冲的场合,还具有编码器的接口。
从时钟源方面来说,通用定时器比基本定时器多了一个选择,它可以使用外部脉冲作为定时器的时钟源。使用外部时钟源时,要使用寄存器进行触发边沿、滤波器带宽的配置。如果选择内部时钟源的话则与基本定时器一样,也为CK_TIMER。但要注意的是,所有定时器(包括基本、通用和高级)使用内部时钟时,定时器的时钟源都被称为CK_TIMER,但CK_TIMER的时钟来源并不是完全一样的,见下图。
基本定时器和部分通用定时器的时钟来源是 APB1 预分频器的输出。当 APB1 的分频系数为 1 时,则CK_TIMER直接等于该APB1 预分频器的输出,而 APB1 的分频系数 不 为 1 时,CK_TIMER则为APB1 预分频器输出的 2 倍。
如在常见的配置中,AHB=120MHz,而 APB1 预分频器的分频系数被配置为2,则PCLK1 刚好达到最大值60MHz,而此时APB1的分频系数不为 1,则CK_TIMER = (AHB/2) x 2 = 120MHz。
而对于部分通用定时器和高级定时器的时钟来源则是 APB2 预分频器的输出,同样它也根据分频系数分为两种情况。
常见的配置中 AHB=120MHz,APB2 预分频器的分频系数被配置为1,此时PCLK2刚好达到最大值120MHz,而CK_TIMER则直接等于APB2分频器的输出,即CK_TIMER的时钟 CK_TIMER =AHB=120MHz。
虽然这种配置下最终CK_TIMER的时钟频率相等,但必须清楚实质上它们的时钟来源是有区别的。还要强调的是:CK_TIMER是定时器内部的时钟源,但在时钟输出到脉冲计数器 TIMERx_CNT 前,还经过一个预分频器TIMERx_PSC,最终用于驱动脉冲计数器 TIMERx_CNT 的时钟频率根据预分频器 TIMERx_PSC 的配置而定。
7.1.3高级定时器
TIMER0和TIMER7定时器的功能包括:
● 16位向上、向下、向上/下自动装载计数器;
● 16位可编程(可以实时修改)预分频器,计数器时钟频率的分频系数为1 ~ 65535之间的任意数值;
● 多达4个独立通道:输入捕获,输出比较,PWM生成(边缘或中间对齐模式),单脉冲模式输出;
● 死区时间可编程的互补输出;
● 使用外部信号控制定时器和定时器互联的同步电路;
● 允许在指定数目的计数器周期之后更新定时器寄存器的重复计数器;
● 刹车输入信号可以将定时器输出信号置于复位状态或者一个已知状态;
● 如下事件发生时产生中断/DMA:更新:计数器向上溢出/向下溢出,计数器初始化(通过软件或者内部/外部触发),触发事件(计数器启动、停止、初始化或者由内部/外部触发计数),输入捕获,输出比较,刹车信号输入;
● 支持针对定位的增量(正交)编码器和霍尔传感器电路;
● 时钟源可选:内部时钟,内部触发,外部输入,外部触发;
总的来说,TIMER0和TIMER7是两个高级定时器,它们具有基本、通用定时器的所有功能,还具有三相 6 步电机的接口、刹车功能(break function)及用于 PWM 驱动电路的死区时间控制等,使得它非常适合于电机的控制。如下图所示为高级定时器结构。
相比于通用定时器,主要多出了BRK、DTG 两个结构,因而具有了死区时间的控制功能。首先,死区时间是什么呢?在 H 桥、三相桥的 PWM 驱动电路中,上下两个桥臂的PWM 驱动信号是互补的,即上下桥臂轮流导通,但实际上为了防止出现上下两个臂同时导通(会造成短路),在上下两臂切换时留一小段时间,上下臂都施加关断信号,这个上下臂都关断的时间称为死区时间。
高级定时器可以配置出输出互补的 PWM 信号,并且在这个 PWM 信号中加入死区时间,为电机的控制提供了极大的便利。下图中的 OCxREF 为参考信号(可理解为原信号),OCx_O和 OCx_ON 为定时器通过 GPIO 引脚输出的 PWM 互补信号。
若不加入死区时间,当OxCPRE出现下降沿,OCx_O同时输出下降沿,OCx_ON 则同时输出相反的上升沿,即这三个信号的跳变是同时的。
加入死区时间后,当 OxCPRE出现下降沿,OCx_O同时输出下降沿,但 OCx_ON 则过了一小段延迟再输出上升沿,OxCPRE出现上升沿后,OCx_O要经过一段延时再输出上升沿。假如 OCx_O、 OCx_ON 分别控制上、下桥臂,有了延迟后,就不容易出现上、下桥臂同时导通的情况。这个延迟时间与 PWM 信号驱动的电子器件特性相关,从事工控领域的朋友对此应该比较熟悉。
7.2定时器计数模式
定时器可以向上计数、向下计数、向上向下双向计数模式。
- 向上计数模式:计数器从0计数到自动加载值(TIMERx_CAR),然后重新从0开始计数并且产生一个计数器溢出事件。
- 向下计数模式:计数器从自动装入的值(TIMERx_CAR)开始向下计数到0,然后从自动装入的值重新开始,并产生一个计数器向下溢出事件。
- 中央对齐模式(向上/向下计数):计数器从0开始计数到自动装入的值-1,产生一个计数器溢出事件,然后向下计数到1并且产生一个计数器溢出事件;然后再从0开始重新计数。
简单地理解三种计数模式,可以通过下面的图形:
计数器时钟可由下列时钟源提供:
- 内部时钟(CK_TIMER);
外部时钟模式0:定时器选择外部输入引脚作为时钟源;
外部时钟模式1:定时器选择外部输入引脚ETI作为时钟源;
7.3定时器的寄存器分析
为了深入了解 GD32 的通用寄存器,下面我们先介绍一下与我们这章的实验密切相关的几个通用定时器的寄存器。首先是控制寄存器0(TIMERx_CTL0),该寄存器的各位描述如下图。
首先我们来看看TIMERx_CTL0的最低位,也就是计数器使能位,该位必须置1,才能让定时器开始计数。 从第 4 位 DIR 可以看出默认的计数方式是向上计数, 同时也可以向下计数,第 5,6位是设置计数对齐方式的。从第 8 和第 9 位可以看出,我们还可以设置定时器的时钟分频因子为1,2,4。
接下来介绍第二个与我们这章密切相关的寄存器:DMA/中断使能寄存器(TIMERx_DMAINTEN)。该寄存器是一个 16 位的寄存器,其各位描述如下图所示。
这里我们同样仅关心它的第 0 位,该位是更新中断允许位,本章用到的是定时器的更新中断,所以该位要设置为1。
接下来我们看第三个与我们这章有关的寄存器:预分频寄存器(TIMERx_PSC)。该寄存器用设置对时钟进行分频,然后提供给计数器,作为计数器的时钟。该寄存器的各位描述如下图。
这里顺带介绍一下TIMERx_CNT 寄存器,该寄存器是定时器的计数器,该寄存器存储了当前定时器的计数值。
接着我们介绍计数器自动重载寄存器(TIMERx_CAR),该寄存器在物理上实际对应着 2 个寄存器。一个是程序员可以直接操作的,另外一个是程序员看不到的,这个看不到的寄存器在《GD32F20x_User_Manual_EN_Rev2.4》里面被叫做影子寄存器。事实上真正起作用的是影子寄存器。根据TIMERx_CTL0寄存器中ARSE位的设置:ARSE=0 时,预装载寄存器的内容可以随时传送到影子寄存器,此时二者是连通的;而 ARSE=1 时,在每一次更新事件时,才把预装在寄存器的内容传送到影子寄存器。自动重装载寄存器的各位描述如下图。
最后,我们要介绍的寄存器是:中断标志寄存器(TIMERx_INTF)。该寄存器用来标记当前与定时器相关的各种事件/中断是否发生。该寄存器的各位描述如下图。
关于这些位的详细描述,请参考《GD32F20x_User_Manual_EN_Rev2.4》。只要对以上几个寄存器进行简单的设置,我们就可以使用通用定时器了,并且可以产生中断。这一章,我们将使用定时器产生中断,然后在中断服务函数里面翻转 DS1 上的电平,来指示定时器中断的产生。
7.4定时器代码实现
接下来我们以通用定时器TIMER1为实例,来说明要经过哪些步骤,才能达到这个要求,并产生中断。
7.4.1定时器配置步骤
这里我们就对每个步骤通过库函数的实现方式来描述。首先要提到的是,定时器相关的库函数主要集中在固件库文件 gd32f20x_timer.h 和 gd32f20x_timer.c 文件中。
1) TIMER1时钟使能。
TIMER1是挂载在 APB1 之下,所以我们通过 APB1 总线下的使能使能函数来使能 TIMER1。调用的函数是:
rcu_periph_clock_enable(RCU_TIMER1);
2) 初始化定时器参数,设置自动重装值,分频系数,计数方式等。
在库函数中,定时器的初始化参数是通过初始化函数 timer_init实现的:
void timer_init(uint32_t timer_periph, timer_parameter_struct *initpara)
第一个参数是确定是哪个定时器,这个比较容易理解。
第二个参数是定时器初始化参数结构体指针,结构体类型为 timer_parameter_struct,下面我们看看这个结构体的定义:
/* TIMER init parameter structure definitions */
typedef struct {
uint16_t prescaler; /*!< prescaler value */
uint16_t alignedmode; /*!< aligned mode */
uint16_t counterdirection; /*!< counter direction */
uint32_t period; /*!< period value */
uint16_t clockdivision; /*!< clock division value */
uint8_t repetitioncounter; /*!< the counter repetition value */
} timer_parameter_struct;
这个结构体一共有6个成员变量。
第一个参数 prescaler 是用来设置分频系数的,刚才上面有讲解。
第二个参数alignedmode是对齐模式,分为边沿对齐模式,中央对齐向下计数置1模式,中央对齐向上计数置1模式,中央对齐上下计数置1模式。
第三个参数counterdirection是计数方向,向上计数和向下计数。
第四个参数period是设置自动重载计数周期值,这在前面也已经讲解过。
第五个参数clockdivision是用来设置时钟分频因子。
第六个参数repetitioncounter是重复计数器。
针对 TIMER1初始化范例代码格式:
timer_parameter_struct timer_initpara;
/* TIMER1 configuration */
timer_initpara.prescaler = 119;
timer_initpara.alignedmode = TIMER_COUNTER_EDGE;
timer_initpara.counterdirection = TIMER_COUNTER_UP;
timer_initpara.period = 999;
timer_initpara.clockdivision = TIMER_CKDIV_DIV1;
timer_initpara.repetitioncounter = 0;
timer_init(TIMER1, &timer_initpara);
3) 设置TIMERx_DMAINTEN允许更新中断。
因为我们要使用 TIMER1 的更新中断, 寄存器的相应位便可使能更新中断。 在库函数里面定时器中断使能是通过 timer_interrupt_enable()函数来实现的:
void timer_interrupt_enable(uint32_t timer_periph, uint32_t interrupt)
第一个参数是选择定时器号,这个容易理解,取值为TIMERx(x=0..13)。
第二个参数非常关键,是用来指明我们使能的定时器中断的类型,定时器中断的类型有很多种,包括TIMER_INT_UP, TIMER_INT_TRG等等。
例如我们要使能 TIMER1 的更新中断,格式为:
timer_interrupt_enable(TIMER1,TIMER_INT_UP);
4) TIMER1 中断优先级设置。
在定时器中断使能之后,因为要产生中断,必不可少的要设置 NVIC 相关寄存器,设置中断优先级。中断优先级配置代码如下:
//TIMER1 interrupt setting, preemptive priority 0, sub-priority 3
nvic_irq_enable(TIMER1_IRQn, 0, 3);
5) 允许 TIMER1工作,也就是使能 TIMER1。
光配置好定时器还不行,没有开启定时器,照样不能用。我们在配置完后要开启定时器,通过 TIMER_CTL0的TIMER_CTL0_CEN位来设置。在固件库里面使能定时器的函数是通过 timer_enable()函数来实现的:
void timer_enable(uint32_t timer_periph)
这个函数非常简单,比如我们要使能定时器1,方法为:
/* TIMER1 enable */
timer_enable(TIMER1);
6) 编写中断服务函数。
在最后,还是要编写定时器中断服务函数,通过该函数来处理定时器产生的相关中断。在中断产生后,通过状态寄存器的值来判断此次产生的中断属于什么类型。然后执行相关的操作,我们这里使用的是更新(溢出)中断,所以在中断标志寄存器TIMERx_INTF的最低位。在处理完中断之后应该向TIMERx_INTF最低位写 0,来清除该中断标志。
在固件库函数里面,用来读取中断状态寄存器的值判断中断类型的函数是:
FlagStatus timer_interrupt_flag_get(uint32_t timer_periph, uint32_t int_flag)
该函数的作用是,判断定时器 TIMERx 的中断类型是否发生中断。比如,我们要判断定时器1是否发生更新(溢出)中断,方法为:
if ( timer_interrupt_flag_get(TIMER1 , TIMER_INT_UP) != RESET ) {}
固件库中清除中断标志位的函数是:
void timer_interrupt_flag_clear(uint32_t timer_periph, uint32_t interrupt)
该函数的作用是,清除定时器 TIMERx 的中断标志位。 使用起来非常简单,比如我们在TIMER1 的溢出中断发生后,我们要清除中断标志位,方法是:
timer_interrupt_flag_clear(TIMER1 , TIMER_INT_UP);
这里需要说明一下,固件库还提供了两个函数用来判断定时器状态以及清除定时器状态标志位的函数 timer_flag_get()和timer_flag_clear(),他们的作用和前面两个函数的作用类似。只是在 timer_interrupt_flag_get()函数中会先判断这种中断是否使能,使能了才去判断中断标志位,而timer_flag_get()直接用来判断状态标志位。
通过以上几个步骤,我们就可以达到我们的目的了,使用通用定时器的更新中断,来控制LED的亮灭。
最后定时器核心配置代码如下:
/*
brief configure the TIMER peripheral
param[in] none
param[out] none
retval none
*/
void timer1_init(void)
{
/* TIMER1 configuration: generate PWM signals with different duty cycles:
TIMER1CLK = SystemCoreClock / 120 = 1MHz */
timer_parameter_struct timer_initpara;
//Enable TIMER1 clock
rcu_periph_clock_enable(RCU_TIMER1);
timer_deinit(TIMER1);
/* TIMER1 configuration */
timer_initpara.prescaler = 119;
timer_initpara.alignedmode = TIMER_COUNTER_EDGE;
timer_initpara.counterdirection = TIMER_COUNTER_UP;
timer_initpara.period = 999;
timer_initpara.clockdivision = TIMER_CKDIV_DIV1;
timer_init(TIMER1, &timer_initpara);
timer_interrupt_enable(TIMER1,TIMER_INT_UP); /* 使能更新中断 */
//TIMER1 interrupt setting, preemptive priority 0, sub-priority 3
nvic_irq_enable(TIMER1_IRQn, 0, 3);
/* TIMER1 enable */
timer_enable(TIMER1);
rcu_periph_clock_disable(RCU_TIMER1);/*先关闭等待使用*/
}
中断代码如下:
/**
* @brief This function handles TIMER1 interrupt request.
* @param None
* @retval None
*/
void TIMER1_IRQHandler(void)
{
if ( timer_interrupt_flag_get(TIMER1 , TIMER_INT_UP) != RESET )
{
time++;
timer_interrupt_flag_clear(TIMER1 , TIMER_INT_UP);
}
}
主函数如下:
/*
brief main function
param[in] none
param[out] none
retval none
*/
int main(void)
{
//systick init
sysTick_init();
/* configure the TIMER peripheral */
timer1_init();
/* configure LED1 GPIO port */
led_init(LED1);
/* configure LED2 GPIO port */
led_init(LED2);
/* configure LED3 GPIO port */
led_init(LED3);
/* configure LED4 GPIO port */
led_init(LED4);
//Enable TIMER1 clock
rcu_periph_clock_enable(RCU_TIMER1);
while(1)
{
if ( time ==1000 ) /* 1000 * 1 ms = 1s 时间到 */
{
time = 0;
/* LED 取反 */
led_toggle(LED1);
led_toggle(LED2);
led_toggle(LED3);
led_toggle(LED4);
}
}
}
接下来分析下定时器溢出时间。
7.4.2定时器溢出时间计算
1.定时器的时钟源
定时器时钟CK_TIMER经 APB1 预分频器后分频提供,如果 APB1 预分频系数等于 1,则频率不变,否则频率乘以 2,库函数中 APB1 预分频的系数是 2,即 PCLK1=60M,所以定时器时钟 CK_TIMER=60*2=120M。
其时钟初始化代码在system_stm32f20x.c定义的,这里使用的默认配置,具体时钟设置函数是system_clock_120m_hxtal(),代码如下:
/*!
\brief configure the system clock to 120M by PLL which selects HXTAL(8M) as its clock source
\param[in] none
\param[out] none
\retval none
*/
static void system_clock_120m_hxtal(void)
{
uint32_t timeout = 0U;
uint32_t stab_flag = 0U;
/* enable HXTAL */
RCU_CTL |= RCU_CTL_HXTALEN;
/* wait until HXTAL is stable or the startup time is longer than HXTAL_STARTUP_TIMEOUT */
do {
timeout++;
stab_flag = (RCU_CTL & RCU_CTL_HXTALSTB);
} while((0U == stab_flag) && (HXTAL_STARTUP_TIMEOUT != timeout));
/* if fail */
if(0U == (RCU_CTL & RCU_CTL_HXTALSTB)) {
while(1) {
}
}
/* HXTAL is stable */
/* AHB = SYSCLK */
RCU_CFG0 |= RCU_AHB_CKSYS_DIV1;
/* APB2 = AHB/1 */
RCU_CFG0 |= RCU_APB2_CKAHB_DIV1;
/* APB1 = AHB/2 */
RCU_CFG0 |= RCU_APB1_CKAHB_DIV2;
/* CK_PLL = (CK_PREDIV0) * 10 = 120 MHz */
RCU_CFG0 &= ~(RCU_CFG0_PLLMF | RCU_CFG0_PLLMF_4 | RCU_CFG0_PREDV0_LSB | RCU_CFG0_PLLSEL);
RCU_CFG0 |= (RCU_PLLSRC_HXTAL | RCU_PLL_MUL10);
/* CK_PREDIV0 = (CK_HXTAL) / 5 * 12 /5 = 12 MHz */
RCU_CFG1 &= ~(RCU_CFG1_PREDV0SEL | RCU_CFG1_PLL1MF | RCU_CFG1_PREDV1 | RCU_CFG1_PREDV0);
RCU_CFG1 |= (RCU_PREDV0SRC_CKPLL1 | RCU_PLL1_MUL12 | RCU_PREDV1_DIV5 | RCU_PREDV0_DIV5);
/* enable PLL1 */
RCU_CTL |= RCU_CTL_PLL1EN;
/* wait till PLL1 is ready */
while((RCU_CTL & RCU_CTL_PLL1STB) == 0U) {
}
/* enable PLL */
RCU_CTL |= RCU_CTL_PLLEN;
/* wait until PLL is stable */
while(0U == (RCU_CTL & RCU_CTL_PLLSTB)) {
}
/* select PLL as system clock */
RCU_CFG0 &= ~RCU_CFG0_SCS;
RCU_CFG0 |= RCU_CKSYSSRC_PLL;
/* wait until PLL is selected as system clock */
while(0U == (RCU_CFG0 & RCU_SCSS_PLL)) {
}
}
重点关注以下代码:
/* APB1 = AHB/2 */
RCU_CFG0 |= RCU_APB1_CKAHB_DIV2;
而RCU_APB1_CKAHB_DIV2的定义如下:
#define RCU_APB1_CKAHB_DIV2 CFG0_APB1PSC(4) /*!< APB1 prescaler select CK_AHB/2 */
因此最终到TIMER1上的时钟为120Mhz。
2.定时器频率
TIMER1上的时钟为120Mhz,定时器分频系数为119,因此TIMER1的频率为
CK_CNT=TIMERxCLK/(PSC+1)=1MHz
3.自动重装载值
自动重装载寄存器 TIMERx_CAR是一个 16 位的寄存器,这里面装着计数器能计数的最大数值。当计数到这个值的时候,如果使能了中断的话,定时器就产生溢出中断,这里设置的是999。
完整配置参数如下:
Prtscaler (定时器分频系数) : 119
Counter Mode(计数模式) :Up(向上计数模式)
Counter Period(自动重装载值) : 999
CKD(时钟分频因子) : No Division 不分频
定时器溢出时间:
Tout=1/(Tclk /psc) *(arr+1)
本文设置参数为: arr=999 psc=119 Tclk=120Mhz ,因此最终的溢出时间如下:
Tout=1/(120MHz /(119+1)) *(999+1)=1ms
值得注意的是,自动重装载值计算溢出时间要加1,这是因为自动重装载寄存器 TIMERx_CAR是从0开始计数的。
7.5实现现象
将编译好的程序下载到看板子中,可以看到LED不停闪烁。
欢迎访问我的网站
BruceOu的哔哩哔哩
BruceOu的主页
BruceOu的博客
BruceOu的CSDN博客
BruceOu的简书
BruceOu的知乎
资源获取方式
1.关注公众号[嵌入式实验楼]
2.在公众号回复关键词[GD32开发发实战指南]获取资料提取码